Skip to content

refactor: harden exec failure wrapping end to end#1074

Merged
thymikee merged 2 commits into
mainfrom
claude/exec-failure-hardening
Jul 4, 2026
Merged

refactor: harden exec failure wrapping end to end#1074
thymikee merged 2 commits into
mainfrom
claude/exec-failure-hardening

Conversation

@thymikee

@thymikee thymikee commented Jul 4, 2026

Copy link
Copy Markdown
Member

What

Follow-up to #1072 addressing the structural issues that let the missing-processExitError bug class exist, in one pass:

1. requireExecSuccess combinator (+ ~30 site conversions)

requireExecSuccess(result, message, extra?) in src/utils/exec.ts guards an allowFailure result and throws the curated COMMAND_FAILED (flag set via execFailureDetails) itself. Pure guard-and-throw sites collapse from five lines to one expression, and forgetting the flag becomes impossible.

Design note: this is deliberately a result-side combinator, not an ExecOptions.failure knob. Tool providers, adb providers, and CommandExecutorOverride are SDK-supplied callbacks that return results without throwing — an option interpreted at spawn time would silently not fire on those paths. The combinator behaves identically under every executor. extra accepts a function so failure-only work (hint classification) is not paid on success.

Sites with tolerance branches ('found nothing to terminate', isMissingAppErrorOutput, 'already booted'), pre-throw cleanup, or exit-0 reachability keep their explicit shape.

2. Self-enforcing guard

src/__tests__/exec-wrap-guard.test.ts scans src/ and fails when an AppError('COMMAND_FAILED', ...) details literal rebuilds the stdout/stderr/exitCode trio inline (longhand or shorthand) without the helper. Intentional holdouts opt out with // exec-guard-allow: <reason> — every marker documents why that site stays unflagged. The guard immediately proved its worth by finding three wrap sites the July audit missed (daemon/runtime-hints.ts run-as probe, daemon/device-ready.ts not_ready branch, perf.ts export table) — all converted here.

3. Provider-boundary result coercion

coerceExecResult normalizes stdout/stderr to strings (and non-number exitCode to 1 — the same failure branch such a result already landed in at every guard) once at the three boundaries where SDK-supplied callbacks hand results back: executor overrides in runCmd/runCmdStreaming, the apple tool-provider scope, and the android adb-provider scope. The per-site String(result.stdout ?? '') defensiveness in devicectl/simulator/device-ready/target-shutdown/plist is removed — downstream code can now trust the types.

4. Kernel: stderr-excerpt noise stripping + documented details contract

  • firstStderrLine strips adb:/xcrun:/simctl:/error: prefixes before rendering, so agents read device offline instead of adb: error: device offline. The skip/strip lists stay in the kernel deliberately (platforms cannot hook normalizeError); a comment marks the registry escape hatch if the lists grow.
  • AppErrorDetails is now exported and documented — the magic keys (hint, processExitError, retriable, reason, diagnosticId, logPath, stdout/stderr/exitCode) carry types and doc comments. exitCode admits null to mirror raw child-process exit events (signal kills).

5. devicectl wrap dedupe

runIosDevicectl gains tolerateOutput; the devicectl uninstall path in app-install.ts reuses it instead of duplicating the whole wrap + hint resolution. Small behavior note: its failure hint now falls back to the devicectl default hint instead of no hint.

Behavior changes a reviewer should know

  • One test expectation updated: an Android perf failure reason is now device offline (prefix stripped) instead of error: device offline.
  • Converted requireExecSuccess/execFailureDetails sites that previously omitted stdout/exitCode from details now include them.
  • Provider results with non-string output no longer leak undefined into details/messages; non-number exitCodes coerce to 1 (previously they already fell into every failure branch, but with undefined in details).

Testing

  • pnpm check:quick clean, pnpm format applied.
  • Full unit suite: 330 files, 3104 tests pass (one daemon-client abort test is flaky under suite-wide contention only; passes in isolation — known issue).
  • New unit tests: requireExecSuccess success/failure/lazy-extras, coerceExecResult identity + repair, three stderr noise-prefix cases in errors.test.ts.

Relates to ADR 0010 (#1071) section 4 — this PR strengthens "prefer exec.ts errors as-is" by making the curated-message case expressible without hand-rolling.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.5 MB 1.5 MB -864 B
JS gzip 485.3 kB 485.3 kB +42 B
npm tarball 587.9 kB 588.3 kB +392 B
npm unpacked 2.1 MB 2.1 MB +26 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.1 ms 26.3 ms +0.2 ms
CLI --help 47.3 ms 47.4 ms +0.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/8875.js +433 B +127 B
dist/src/apps.js -537 B -100 B
dist/src/3340.js -18 B +33 B
dist/src/debug-symbols.js -114 B -23 B
dist/src/9722.js -438 B -5 B

@thymikee

thymikee commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

CI needs attention before review: Fallow Code Quality is failing on the current head.

Reported gate failures:

  • duplicate 15-line block between src/platforms/android/perf.ts:153-167 and src/platforms/apple/core/perf.ts:194-208
  • new complexity finding in src/__tests__/exec-wrap-guard.test.ts:69 (9 cyclomatic / 19 cognitive)

Please run pnpm check:fallow --base origin/main, remove or justify the duplication, and simplify/split the test helper before requesting review.

thymikee added 2 commits July 4, 2026 12:35
Follow-up to #1072, closing the structural gaps that let the
missing-processExitError bug class exist:

- requireExecSuccess(result, message, extra?) in utils/exec.ts: guards an
  allowFailure result and throws the curated COMMAND_FAILED (flag set via
  execFailureDetails) itself. Result-side by design — tool providers and
  executor overrides return results without throwing, so an ExecOptions
  knob interpreted at spawn time would silently not fire on those paths.
  ~30 pure guard-and-throw sites across apple/android/web converted; sites
  with tolerance branches, cleanup, or exit-0 reachability keep their
  explicit shape.

- Source-scan guard (src/__tests__/exec-wrap-guard.test.ts): fails when an
  AppError('COMMAND_FAILED', ...) details literal rebuilds the
  stdout/stderr/exitCode trio inline without the helper; intentional
  holdouts opt out with a documented exec-guard-allow comment. The guard
  immediately found three wrap sites the July audit missed (runtime-hints
  run-as probe, device-ready not_ready branch, perf export table) — now
  converted.

- coerceExecResult at the provider boundaries (executor overrides, apple
  tool provider scope, android adb provider scope): SDK-supplied callbacks
  cross an unchecked boundary; coercing once there replaces the per-site
  String(result.stdout ?? '') defensiveness, which is removed.

- normalizeError stderr excerpts now strip noise prefixes (adb:/xcrun:/
  simctl:/error:) before rendering; skip/strip lists stay in the kernel
  deliberately (platforms cannot hook normalizeError) with a comment
  marking the registry escape hatch if they grow.

- AppErrorDetails exported and documented in kernel/errors.ts: the magic
  keys (hint, processExitError, retriable, reason, diagnosticId, logPath,
  stdout/stderr/exitCode) now carry types and doc comments.

- runIosDevicectl gains tolerateOutput; the devicectl uninstall path in
  app-install.ts reuses it instead of duplicating the wrap + hint logic
  (its failure hint now falls back to the devicectl default hint).
Main landed the central adb failure classifier (androidAdbResultError +
withAdbFailureHintProvider) while this branch was in flight. Resolution:

- Android call sites keep main's androidAdbResultError form — it composes
  execFailureDetails with the classified adb hint, which is strictly
  richer than a plain requireExecSuccess conversion for adb invocations.
  requireExecSuccess remains the shape for non-adb tools and apple/web.
- Provider result coercion folds into withAdbFailureHintProvider's
  enrichment pass (exec/pull/install), so the existing WeakSet memo also
  prevents coercer stacking.
- The reverse-remove wrap in adb-executor now uses androidAdbResultError
  instead of hand-rolling the trio (flagged by the exec-wrap guard).
- Guard-test scan split into helpers (fallow cyclomatic threshold) and
  the perf artifact-tail clone carries fallow-ignore markers on both
  platforms, matching the pre-existing marker on the apple side.
- Two expectations updated for the stderr noise-prefix strip: classifier
  compose test and perf sampling reason ('device offline', no 'error:').
@thymikee thymikee force-pushed the claude/exec-failure-hardening branch from a4f0b0e to 5b66fed Compare July 4, 2026 10:43
@thymikee thymikee merged commit c5f7b7b into main Jul 4, 2026
21 checks passed
@thymikee thymikee deleted the claude/exec-failure-hardening branch July 4, 2026 10:56
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-04 10:57 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant